import 'dart:convert' show jsonDecode;
import 'dart:ffi' show Abi;
import 'dart:io';

import 'package:_fe_analyzer_shared/src/testing/annotated_code_helper.dart';
import 'package:dart2js_tools/src/util.dart';
import 'package:expect/legacy/minitest.dart'; // ignore: deprecated_member_use
import 'package:path/path.dart' as path;
import 'package:source_maps/source_maps.dart';

/// Runs D8 and steps as the AnnotatedCode dictates.
///
/// Note that the compiled javascript is expected to be called "js.js" inside
/// the outputPath directory. It is also expected that there is a "js.js.map"
/// file. It is furthermore expected that the js has been compiled from a file
/// in the same folder called test.dart.
ProcessResult runD8AndStep(
  String outputPath,
  String testFileName,
  AnnotatedCode code,
  List<String> scriptD8Command,
) {
  var outputFile = path.join(outputPath, 'js.js');
  var sourcemapText = File('$outputFile.map').readAsStringSync();
  SingleMapping sourceMap = parseSingleMapping(jsonDecode(sourcemapText));

  Set<int?> mappedToLines = sourceMap.lines
      .map((entry) => entry.entries.map((entry) => entry.sourceLine).toSet())
      .fold(<int?>{}, (prev, e) => prev..addAll(e));

  for (Annotation annotation in code.annotations.where(
    (a) => a.text.trim() == 'nm',
  )) {
    if (mappedToLines.contains(annotation.lineNo - 1)) {
      fail(
        'Was not allowed to have a mapping to line '
        '${annotation.lineNo}, but did.\n'
        'Sourcemap looks like this (note 0-indexed):\n'
        '${sourceMap.debugString}',
      );
    }
  }

  List<String?> breakpoints = [];
  // Annotations are 1-based, js breakpoints are 0-based.
  for (Annotation breakAt in code.annotations.where(
    (a) => a.text.trim() == 'bl',
  )) {
    breakpoints.add(
      _getJsBreakpointLine(testFileName, sourceMap, breakAt.lineNo - 1),
    );
  }
  for (Annotation breakAt in code.annotations.where(
    (a) => a.text.trim().startsWith('bc:'),
  )) {
    breakpoints.add(
      _getJsBreakpointLineAndColumn(
        testFileName,
        sourceMap,
        breakAt.lineNo - 1,
        breakAt.columnNo - 1,
      ),
    );
  }

  File inspectorFile = File.fromUri(
    sdkRoot!.uri.resolve('pkg/sourcemap_testing/lib/src/js/inspector.js'),
  );
  if (!inspectorFile.existsSync()) throw "Couldn't find 'inspector.js'";
  var outInspectorPath = path.join(outputPath, 'inspector.js');
  inspectorFile.copySync(outInspectorPath);
  String debugAction = 'Debugger.stepInto';
  if (code.annotations.any((a) => a.text.trim() == 'Debugger:stepOver')) {
    debugAction = 'Debugger.stepOver';
  }
  return _runD8(outInspectorPath, scriptD8Command, debugAction, breakpoints);
}

/// Translates the D8 js steps and checks against expectations.
///
/// Note that the compiled javascript is expected to be called "js.js" inside
/// the outputPath directory. It is also expected that there is a "js.js.map"
/// file. It is furthermore expected that the js has been compiled from a file
/// in the same folder called test.dart.
void checkD8Steps(
  String outputPath,
  List<String> d8Output,
  AnnotatedCode code, {
  bool debug = false,
}) {
  var outputFilename = 'js.js';
  var outputFile = path.join(outputPath, outputFilename);
  var sourcemapText = File('$outputFile.map').readAsStringSync();
  SingleMapping sourceMap = parseSingleMapping(jsonDecode(sourcemapText));

  List<List<_DartStackTraceDataEntry>> result = _extractStackTraces(
    d8Output,
    sourceMap,
    outputFilename,
  );

  List<_DartStackTraceDataEntry> trace =
      result.map((entry) => entry.first).toList();
  if (debug) _debugPrint(trace, outputPath);

  List<String> recordStops =
      trace.where((entry) => !entry.isError).map((entry) => '$entry').toList();

  Set<int> recordStopLines =
      trace.where((entry) => !entry.isError).map((entry) => entry.line).toSet();
  Set<String> recordStopLineColumns =
      trace
          .where((entry) => !entry.isError)
          .map((entry) => '${entry.line}:${entry.column}')
          .toSet();

  List<String?> expectedStops = [];
  for (Annotation annotation in code.annotations.where(
    (annotation) =>
        annotation.text.trim().startsWith('s:') ||
        annotation.text.trim().startsWith('sl:') ||
        annotation.text.trim().startsWith('bc:'),
  )) {
    String text = annotation.text.trim();
    int stopNum = int.parse(text.substring(text.indexOf(':') + 1));
    if (expectedStops.length < stopNum) expectedStops.length = stopNum;
    if (text.startsWith('sl:')) {
      expectedStops[stopNum - 1] = 'test.dart:${annotation.lineNo}:';
    } else {
      expectedStops[stopNum - 1] =
          'test.dart:${annotation.lineNo}:${annotation.columnNo}:';
    }
  }

  List<List<String>?> noBreaksStart = [];
  List<List<String>?> noBreaksEnd = [];
  for (Annotation annotation in code.annotations.where(
    (annotation) => annotation.text.trim().startsWith('nbb:'),
  )) {
    String text = annotation.text.trim();
    var split = text.split(':');
    int stopNum1 = int.parse(split[1]);
    int stopNum2 = int.parse(split[2]);
    if (noBreaksStart.length <= stopNum1) noBreaksStart.length = stopNum1 + 1;
    (noBreaksStart[stopNum1] ??= []).add('test.dart:${annotation.lineNo}:');
    if (noBreaksEnd.length <= stopNum2) noBreaksEnd.length = stopNum2 + 1;
    (noBreaksEnd[stopNum2] ??= []).add('test.dart:${annotation.lineNo}:');
  }

  _checkRecordedStops(
    recordStops,
    expectedStops,
    noBreaksStart,
    noBreaksEnd,
    debug,
  );

  for (Annotation annotation in code.annotations.where(
    (annotation) => annotation.text.trim() == 'nb',
  )) {
    // Check that we didn't break where we're not allowed to.
    if (recordStopLines.contains(annotation.lineNo)) {
      fail(
        'Was not allowed to stop on line ${annotation.lineNo}, but did!'
        '  Actual line stops: $recordStopLines${_debugHint(debug)}',
      );
    }
  }
  for (Annotation annotation in code.annotations.where(
    (annotation) => annotation.text.trim() == 'nbc',
  )) {
    // Check that we didn't break where we're not allowed to.
    if (recordStopLineColumns.contains(
      '${annotation.lineNo}:${annotation.columnNo}',
    )) {
      fail(
        'Was not allowed to stop on line ${annotation.lineNo} '
        'column ${annotation.columnNo}, but did!'
        '  Actual line stops: $recordStopLineColumns${_debugHint(debug)}',
      );
    }
  }

  if (code.annotations.any((a) => a.text.trim() == 'fail')) {
    fail("Test contains 'fail' annotation.");
  }
}

void _checkRecordedStops(
  List<String> recordStops,
  List<String?> expectedStops,
  List<List<String>?> noBreaksStart,
  List<List<String>?> noBreaksEnd,
  bool debug,
) {
  // We want to find all expected lines in recorded lines in order, but allow
  // more in between in the recorded lines.
  // noBreaksStart and noBreaksStart gives instructions on what's *NOT* allowed
  // to be between those points though.

  int expectedIndex = 0;
  Set<String> aliveNoBreaks = {};
  if (noBreaksStart.isNotEmpty && noBreaksStart[0] != null) {
    aliveNoBreaks.addAll(noBreaksStart[0]!);
  }
  int stopNumber = 0;
  for (String recorded in recordStops) {
    stopNumber++;
    if (expectedIndex == expectedStops.length) break;
    var expectedStop = expectedStops[expectedIndex];
    if (expectedStop != null && '$recorded:'.contains(expectedStop)) {
      ++expectedIndex;
      if (noBreaksStart.length > expectedIndex &&
          noBreaksStart[expectedIndex] != null) {
        aliveNoBreaks.addAll(noBreaksStart[expectedIndex]!);
      }
      if (noBreaksEnd.length > expectedIndex &&
          noBreaksEnd[expectedIndex] != null) {
        aliveNoBreaks.removeAll(noBreaksEnd[expectedIndex]!);
      }
    } else {
      if (debug) {
        // One of the most helpful debugging tools is to see stops that weren't
        // matched. The most common failure is we didn't match one particular
        // stop location (e.g. because of the column). This gets reported
        // as an aliveNoBreaks failure (if the test is using no-breaks like
        // `nbb`) or it's reported as "stops don't match" message.
        //
        // Both failures are difficult to debug without seeing the stops that
        // didn't match. No breaks failures are misleading (the problem isn't
        // an incorrect break, but we missed a stop, so the aliveNoBreaks is
        // wrong), and the normal failure list dumps the entire stop list,
        // making it difficult to see where the mismatch was.
        //
        // Also we add 1 to expectedIndex, because the stop annotations are
        // 1-based in the source files (e.g. `/*s:1*/` is expectedIndex 0)
        print(
          "Skipping stop `$recorded` that didn't match expected stop "
          '${expectedIndex + 1} `${expectedStops[expectedIndex]}`',
        );
      }
      if (aliveNoBreaks.contains(
        "${(recorded.split(":")..removeLast()).join(":")}:",
      )) {
        fail(
          "Break '$recorded' was found when it wasn't allowed "
          '(js step $stopNumber, after stop ${expectedIndex + 1}). '
          'This can happen when an expected stop was not matched'
          '${_debugHint(debug)}.',
        );
      }
    }
  }
  if (expectedIndex != expectedStops.length) {
    // Didn't find everything.
    fail(
      'Expected to find $expectedStops but found $recordStops'
      '${_debugHint(debug)}',
    );
  }
}

/// If we're not in debug mode, this returns a message string with information
/// about how to enable debug mode in the test runner.
String _debugHint(bool debug) {
  if (debug) return ''; // already in debug mode
  return ' (pass -Ddebug=1 to the test runner to see debug information)';
}

void _debugPrint(List<_DartStackTraceDataEntry> trace, String outputPath) {
  StringBuffer sb = StringBuffer();
  var jsFile = File(
    path.join(outputPath, 'js.js'),
  ).readAsStringSync().split('\n');
  var dartFile = File(
    path.join(outputPath, 'test.dart'),
  ).readAsStringSync().split('\n');

  List<String> getSnippet(List<String> data, int line, int column) {
    List<String> result = List<String>.filled(5, '');
    if (line < 0 || column < 0) return result;

    for (int i = 0; i < 5; ++i) {
      int j = line - 2 + i;
      if (j < 0 || j >= data.length) continue;
      result[i] = data[j];
    }
    if (result[2].length >= column) {
      var before = result[2].substring(0, column);
      var after = result[2].substring(column);
      result[2] = '$before/*STOP*/$after';
    }
    return result;
  }

  List<String> sideBySide(List<String> a, List<String> b, int columns) {
    List<String> result = List<String>.filled(a.length, '');
    for (int i = 0; i < a.length; ++i) {
      String left = a[i].padRight(columns).substring(0, columns);
      String right = b[i].padRight(columns).substring(0, columns);
      result[i] = '$left  |  $right';
    }
    return result;
  }

  for (int i = 0; i < trace.length; ++i) {
    sb.write('\n\nStop #${i + 1}\n\n');
    if (trace[i].isError && trace[i].jsLine < 0) {
      sb.write('${trace[i].errorString}\n');
      continue;
    }
    var jsSnippet = getSnippet(jsFile, trace[i].jsLine, trace[i].jsColumn);
    var dartSnippet = getSnippet(
      dartFile,
      trace[i].line - 1,
      trace[i].column - 1,
    );
    var view = sideBySide(jsSnippet, dartSnippet, 50);
    sb.writeAll(view, '\n');
  }

  print(sb.toString());
}

List<List<_DartStackTraceDataEntry>> _extractStackTraces(
  List<String> lines,
  SingleMapping sourceMap,
  String outputFilename,
) {
  List<List<_DartStackTraceDataEntry>> result = [];
  bool inStackTrace = false;
  List<String> currentStackTrace = <String>[];
  for (var line in lines) {
    if (line.trim() == '--- Debugger stacktrace start ---') {
      inStackTrace = true;
    } else if (line.trim() == '--- Debugger stacktrace end ---') {
      result.add(
        _extractStackTrace(currentStackTrace, sourceMap, outputFilename),
      );
      currentStackTrace.clear();
      inStackTrace = false;
    } else if (inStackTrace) {
      currentStackTrace.add(line.trim());
    }
  }
  return result;
}

List<_DartStackTraceDataEntry> _extractStackTrace(
  List<String> js,
  SingleMapping sourceMap,
  String wantedFile,
) {
  List<_DartStackTraceDataEntry> result = [];
  for (String line in js) {
    if (!line.contains('$wantedFile:')) {
      result.add(_DartStackTraceDataEntry.error("Not correct file @ '$line'"));
      continue;
    }
    Iterable<Match> ms = RegExp(r'(\d+):(\d+)').allMatches(line);
    if (ms.isEmpty) {
      result.add(
        _DartStackTraceDataEntry.error("Line and column not found for '$line'"),
      );
      continue;
    }
    Match m = ms.first;
    int l = int.parse(m.group(1)!);
    int c = int.parse(m.group(2)!);
    SourceMapSpan? span = _getColumnOrPredecessor(sourceMap, l, c);
    if (span?.start == null) {
      result.add(
        _DartStackTraceDataEntry.errorWithJsPosition(
          "Source map not found for '$line'",
          l,
          c,
        ),
      );
      continue;
    }
    var file = span!.sourceUrl?.pathSegments.last ?? '(unknown file)';
    result.add(
      _DartStackTraceDataEntry(
        file,
        span.start.line + 1,
        span.start.column + 1,
        l,
        c,
      ),
    );
  }
  return result;
}

SourceMapSpan? _getColumnOrPredecessor(
  SingleMapping sourceMap,
  int line,
  int column,
) {
  SourceMapSpan? span = sourceMap.spanFor(line, column);
  if (span == null && line > 0) {
    span = sourceMap.spanFor(line - 1, 999999);
  }
  return span;
}

class _DartStackTraceDataEntry {
  final String? file;
  final int line;
  final int column;
  final String? errorString;
  final int jsLine;
  final int jsColumn;

  _DartStackTraceDataEntry(
    this.file,
    this.line,
    this.column,
    this.jsLine,
    this.jsColumn,
  ) : errorString = null;
  _DartStackTraceDataEntry.error(this.errorString)
    : file = null,
      line = -1,
      column = -1,
      jsLine = -1,
      jsColumn = -1;
  _DartStackTraceDataEntry.errorWithJsPosition(
    this.errorString,
    this.jsLine,
    this.jsColumn,
  ) : file = null,
      line = -1,
      column = -1;

  bool get isError => errorString != null;

  @override
  String toString() => isError ? errorString! : '$file:$line:$column';
}

class _PointMapping {
  final int fromLine;
  final int fromColumn;
  final int toLine;
  final int toColumn;

  _PointMapping(this.fromLine, this.fromColumn, this.toLine, this.toColumn);
}

/// Input and output is expected to be 0-based.
///
/// The "magic 4" below is taken from
/// https://github.com/ChromeDevTools/devtools-frontend/blob/fa18d70a995f06cb73365b2e5b8ae974cf60bd3a/front_end/sources/JavaScriptSourceFrame.js#L1520-L1523
String? _getJsBreakpointLine(
  String testFileName,
  SingleMapping sourceMap,
  int breakOnLine,
) {
  List<_PointMapping> mappingsOnLines = [];
  for (var line in sourceMap.lines) {
    for (var entry in line.entries) {
      final sourceLine = entry.sourceLine;
      if (sourceLine == null) continue;
      final sourceColumn = entry.sourceColumn!;
      final sourceUrlId = entry.sourceUrlId;
      if (sourceLine >= breakOnLine &&
          sourceLine < breakOnLine + 4 &&
          sourceUrlId != null &&
          sourceMap.urls[sourceUrlId] == testFileName) {
        mappingsOnLines.add(
          _PointMapping(sourceLine, sourceColumn, line.line, entry.column),
        );
      }
    }
  }

  if (mappingsOnLines.isEmpty) return null;

  mappingsOnLines.sort((a, b) {
    if (a.fromLine != b.fromLine) return a.fromLine - b.fromLine;
    if (a.fromColumn != b.fromColumn) return a.fromColumn - b.fromColumn;
    if (a.toLine != b.toLine) return a.toLine - b.toLine;
    return a.toColumn - b.toColumn;
  });
  _PointMapping first = mappingsOnLines.first;
  mappingsOnLines.retainWhere((p) => p.toLine >= first.toLine);

  _PointMapping last = mappingsOnLines.last;
  return '${first.toLine}:${first.toColumn}:${last.toLine}:${first.toColumn}';
}

/// Input and output is expected to be 0-based.
String? _getJsBreakpointLineAndColumn(
  String testFileName,
  SingleMapping sourceMap,
  int breakOnLine,
  int breakOnColumn,
) {
  for (var line in sourceMap.lines) {
    for (var entry in line.entries) {
      if (entry.sourceLine == breakOnLine &&
          entry.sourceColumn == breakOnColumn &&
          entry.sourceUrlId != null &&
          sourceMap.urls[entry.sourceUrlId!] == testFileName) {
        return '${line.line}:${entry.column}';
      }
    }
  }
  return null;
}

ProcessResult _runD8(
  String outInspectorPath,
  List<String> scriptD8Command,
  String debugAction,
  List<String?> breakpoints,
) {
  var outInspectorPathRelative = path.relative(outInspectorPath);
  ProcessResult runResult = Process.runSync(d8Executable, [
    '--enable-inspector',
    outInspectorPathRelative,
    ...scriptD8Command,
    '--',
    debugAction,
    ...breakpoints.nonNulls,
  ]);
  if (runResult.exitCode != 0) {
    print(runResult.stderr);
    print(runResult.stdout);
    throw 'Exit code: ${runResult.exitCode} from d8';
  }
  return runResult;
}

File? _cachedD8File;
Directory? _cachedSdkRoot;
File getD8File() {
  File attemptFileFromDir(Directory dir) {
    final arch = Abi.current().toString().split('_')[1];
    if (Platform.isWindows) {
      return File(
        '${dir.path}${Platform.pathSeparator}d8/windows/$arch/d8.exe',
      );
    } else if (Platform.isLinux) {
      return File('${dir.path}${Platform.pathSeparator}d8/linux/$arch/d8');
    } else if (Platform.isMacOS) {
      return File('${dir.path}${Platform.pathSeparator}d8/macos/$arch/d8');
    }
    throw UnsupportedError('Unsupported platform.');
  }

  File search() {
    Directory dir = File.fromUri(Platform.script).parent;
    while (dir.path.length > 1) {
      for (var entry in dir.listSync()) {
        if (entry is! Directory) continue;
        if (entry.path.endsWith('third_party')) {
          List<String> segments = entry.uri.pathSegments;
          if (segments[segments.length - 2] == 'third_party') {
            File possibleD8 = attemptFileFromDir(entry);
            if (possibleD8.existsSync()) {
              _cachedSdkRoot = dir;
              return possibleD8;
            }
          }
        }
      }
      dir = dir.parent;
    }

    throw 'Cannot find D8 directory.';
  }

  return _cachedD8File ??= search();
}

Directory? get sdkRoot {
  getD8File();
  return _cachedSdkRoot;
}

String get d8Executable {
  return getD8File().path;
}
